Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.43% covered (warning)
71.43%
75 / 105
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApieContext
71.43% covered (warning)
71.43%
75 / 105
50.00% covered (danger)
50.00%
6 / 12
147.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
1.00
 withContext
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 hasContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getContext
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 registerOrMarkAmbiguous
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 registerInstance
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getApplicableGetters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 getApplicableSetters
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 getApplicableMethods
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
8.50
 appliesToContext
66.67% covered (warning)
66.67%
18 / 27
0.00% covered (danger)
0.00%
0 / 1
25.48
 checkAuthorization
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 isAuthorized
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2namespace Apie\Core\Context;
3
4use Apie\Core\Attributes\ApieContextAttribute;
5use Apie\Core\Attributes\Internal;
6use Apie\Core\Attributes\RuntimeCheck;
7use Apie\Core\Attributes\StaticCheck;
8use Apie\Core\ContextConstants;
9use Apie\Core\Entities\EntityWithStatesInterface;
10use Apie\Core\Exceptions\ActionNotAllowedException;
11use Apie\Core\Exceptions\IndexNotFoundException;
12use Apie\Core\Metadata\Concerns\UseContextKey;
13use Apie\Core\Utils\EntityUtils;
14use LogicException;
15use ReflectionClass;
16use ReflectionEnumUnitCase;
17use ReflectionMethod;
18use ReflectionProperty;
19use ReflectionType;
20use Throwable;
21
22/**
23 * ApieContext is used as builder/mediator and passed though many Apie functions. It can be used to filter (for example
24 * only show property when authenticated) or can be used to provide extra functionality to other methods.
25 */
26final class ApieContext
27{
28    use UseContextKey;
29
30    /** @var array<int, class-string<ApieContextAttribute>> */
31    private const ATTRIBUTES = [
32        StaticCheck::class
33    ];
34    /** @var array<string, \Closure> */
35    private array $predefined;
36
37    /**
38     * @param array<string, mixed> $context
39     */
40    public function __construct(private array $context = [])
41    {
42        $this->predefined = [
43            ApieContext::class => function () {
44                return $this;
45            }
46        ];
47    }
48
49    public function withContext(string $key, mixed $value): self
50    {
51        $instance = clone $this;
52        $instance->context[$key] = $value;
53        return $instance;
54    }
55
56    public function hasContext(string $key): bool
57    {
58        return array_key_exists($key, $this->context) || isset($this->predefined[$key]);
59    }
60
61    public function getContext(string $key, bool $throwError = true): mixed
62    {
63        if (isset($this->predefined[$key])) {
64            return $this->predefined[$key]();
65        }
66        if (!array_key_exists($key, $this->context)) {
67            if (!$throwError) {
68                return null;
69            }
70            throw new IndexNotFoundException($key);
71        }
72
73        return $this->context[$key];
74    }
75
76    private function registerOrMarkAmbiguous(string $offset, object $instance): void
77    {
78        if (!isset($this->context[$offset])) {
79            $this->context[$offset] = $instance;
80            return;
81        }
82        if ($this->context[$offset] instanceof AmbiguousCall) {
83            $this->context[$offset] = $this->context[$offset]->withAddedName(get_class($instance));
84        } else {
85            $this->context[$offset] = new AmbiguousCall($offset, get_class($this->context[$offset]), get_class($instance));
86        }
87    }
88
89    public function registerInstance(object $object): self
90    {
91        $refl = new ReflectionClass($object);
92
93        $instance = $this->withContext($refl->name, $object);
94        foreach ($refl->getInterfaceNames() as $interface) {
95            $instance->registerOrMarkAmbiguous($interface, $object);
96        }
97        $refl = $refl->getParentClass();
98        while ($refl) {
99            $instance->registerOrMarkAmbiguous($refl->name, $object);
100            $refl = $refl->getParentClass();
101        }
102
103        return $instance;
104    }
105
106    /**
107     * @param ReflectionClass<object> $class
108     */
109    public function getApplicableGetters(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
110    {
111        $list = [];
112        foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
113            if ($this->appliesToContext($property, $runtimeChecks)) {
114                $list[$property->getName()] = $property;
115            }
116        }
117        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
118            if (preg_match('/^(get|has|is).+$/i', $method->name) && $this->appliesToContext($method, $runtimeChecks) && !$method->isStatic() && !$method->isAbstract()) {
119                if (strpos($method->name, 'is') === 0) {
120                    $list[lcfirst(substr($method->name, 2))] = $method;
121                } else {
122                    $list[lcfirst(substr($method->name, 3))] = $method;
123                }
124            }
125        }
126        return new ReflectionHashmap($list);
127    }
128
129    /**
130     * @param ReflectionClass<object> $class
131     */
132    public function getApplicableSetters(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
133    {
134        $list = [];
135        foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
136            if ($property->isReadOnly()) {
137                continue;
138            }
139            if ($this->appliesToContext($property, $runtimeChecks)) {
140                $list[$property->getName()] = $property;
141            }
142        }
143        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
144            if (preg_match('/^(set).+$/i', $method->name) && $this->appliesToContext($method, $runtimeChecks) && !$method->isStatic() && !$method->isAbstract()) {
145                $list[lcfirst(substr($method->name, 3))] = $method;
146            }
147        }
148        return new ReflectionHashmap($list);
149    }
150
151    /**
152     * @param ReflectionClass<object> $class
153     */
154    public function getApplicableMethods(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
155    {
156        $list = [];
157        $filter = function (ReflectionMethod $method) {
158            return !preg_match('/^(__|create|set|get|has|is).+$/i', $method->name);
159        };
160        if ($runtimeChecks && $this->hasContext(ContextConstants::RESOURCE)) {
161            $resource = $this->getContext(ContextConstants::RESOURCE);
162            if ($resource instanceof EntityWithStatesInterface) {
163                $allowedMethods = $resource->provideAllowedMethods()->toArray();
164                $allowedMethodsMap = array_combine($allowedMethods, $allowedMethods);
165                $filter = function (ReflectionMethod $method) use (&$allowedMethodsMap) {
166                    return isset($allowedMethodsMap[$method->name]);
167                };
168            }
169        }
170        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
171            if ($this->appliesToContext($method, $runtimeChecks) && $filter($method)) {
172                $list[$method->name] = $method;
173            }
174        }
175        return new ReflectionHashmap($list);
176    }
177
178    /**
179     * @param ReflectionClass<object>|ReflectionMethod|ReflectionProperty|ReflectionType|ReflectionEnumUnitCase $method
180     */
181    public function appliesToContext(ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionType|ReflectionEnumUnitCase $method, bool $runtimeChecks = true, ?Throwable $errorToThrow = null): bool
182    {
183        if ($method->getAttributes(Internal::class)) {
184            return false;
185        }
186        $attributesToCheck = $runtimeChecks ? [RuntimeCheck::class, ...self::ATTRIBUTES] : self::ATTRIBUTES;
187        foreach ($attributesToCheck as $attribute) {
188            foreach ($method->getAttributes($attribute) as $reflAttribute) {
189                if (!$reflAttribute->newInstance()->applies($this)) {
190                    if ($errorToThrow) {
191                        throw $errorToThrow;
192                    }
193                    return false;
194                }
195            }
196        }
197        if ($method instanceof ReflectionMethod && $runtimeChecks) {
198            foreach (EntityUtils::getContextParameters($method) as $parameter) {
199                if ($parameter->isDefaultValueAvailable()) {
200                    continue;
201                }
202                $key = $this->getContextKey($this, $parameter);
203                if ($key === null) {
204                    if ($errorToThrow) {
205                        throw new LogicException(
206                            'Parameter ' . $parameter->name . ' has an invalid context key',
207                            0,
208                            $errorToThrow
209                        );
210                    }
211                    return false;
212                }
213                if (!isset($this->context[$key]) && !isset($this->predefined[$key])) {
214                    if ($errorToThrow) {
215                        throw new IndexNotFoundException($key);
216                    }
217                    return false;
218                }
219            }
220        }
221        return true;
222    }
223
224    public function checkAuthorization(): void
225    {
226        try {
227            if (!$this->isAuthorized(runtimeChecks: true, throwError: true)) {
228                throw new ActionNotAllowedException();
229            }
230        } catch (ActionNotAllowedException) {
231            throw new ActionNotAllowedException();
232        } catch (Throwable $error) {
233            throw new ActionNotAllowedException($error);
234        }
235    }
236
237    public function isAuthorized(bool $runtimeChecks, bool $throwError = false): bool
238    {
239        $actionClass = $this->getContext(ContextConstants::APIE_ACTION, $throwError);
240        if (!$actionClass) {
241            return true;
242        }
243        return $actionClass::isAuthorized($this, $runtimeChecks, $throwError);
244    }
245}